test

4.5 陷阱与伪优化

本节会对Java中与线程和同步相关的陷阱进行介绍。

4.5.1 问题方法:Thread.stop Thread.resume Thread.suspend

Java线程相关API中最严重的问题是对java.lang.Thread类中stop resumesuspend方法的调用。这些方法从Java 1.0版本中就存在,但很快就被发现存在问题,并且不推荐再使用。尽管如此,还是为时已晚,尽管已经发出警告,对这些方法的调用至今仍散落在世界各地的历史遗留代码中,而且不少新近的应用程序也仍在使用,本书的作者曾见到过2008年开发的代码中还在使用这些方法。

stop方法用于终止线程的执行,但却并不安全,这是因为如果该线程正在修改全局数据,那么终止线程可能会使数据不一致,破坏应有状态。接收到终止信号的线程会释放其持有的锁,使正在被修改的数据对其他线程可见,这违反了Java的沙箱模型。

因此,普遍建议使用wait方法、notify方法或volatile变量来做线程间的同步处理。

使用suspend方法挂起一个线程可能会产生死锁,即如果线程 T1获取了锁对象 L1,但却被挂起了,此时另一个线程 T2试图获取锁 L1就会被阻塞住,直到线程 T1重新恢复执行并释放该锁。但如果负责调用resume方法来唤醒线程 T1的线程 T3也想获取锁 L1,于是乎线程 T3也会被阻塞住,这时就形成了死锁。因此,Thread.resume方法和Thread.suspend方法也因为过于危险而被弃用。

因此,永远不要使用Thread.stop方法、Thread.resume方法或Thread.syspend方法,并小心处理历史遗留代码中对这些方法的使用。

4.5.2 双检查锁

如果对内存模型和CPU架构缺乏理解的话,即使使用平台独立性很高的Java做开发一样会遇到问题。以下面的代码为例,其目的是实现单例模式:

public class GadgetHolder {
    private Gadget theGadget;
    public synchronized Gadget getGadget() {
        if (this.theGadget == null) {
            this.theGadget = new Gadget();
        }
        return this.theGadget;
    }
}

上面的代码是线程安全的,因为getGadget方法是同步的,以自身实例作为隐式监视器。但当Gadget类的构造函数已经执行过一次之后,再执行同步操作看起来有些浪费,因此,为了优化性能,将之改造为下面的代码:

public Gadget getGadget() {
    if (this.theGadget == null) {
        synchronized(this) {
            if (this.theGadget == null) {
                this.theGadget = new Gadget();
            }
        }
    }
    return this.theGadget;
}

上面的代码使用了一个看起来很"聪明"的技巧,如果对象已经存在,则将之返回,不再执行同步操作,而是直接返回已有的对象,如果对象还未创建,则进入同步代码块,创建对象并赋值。这样可以保证 "线程安全"

上面代码的就是所谓的 双检查锁(double checked locking),下面分析一下这段代码的问题。假设某个线程经过内层的空值检查,开始初始化theGadget字段的值,该线程需要为新对象分配内存,并对theGadget字段赋值。可是,这一系列操作并不是原子的,且执行顺序无法保证。如果在此时正好发生线程上下文切换,则另一个线程看到的theGadget字段的值可能是未经完整初始化的,有可能会导致外层的控制检查失效,并返回这个未经完整初始化的对象。不仅仅是创建对象可能会出问题,处理其他类型数据时也要小心。例如,在32位平台上,写入一个long型数据通常需要执行2次32位数据的写操作,而写入int数据则无此顾虑。

上述问题可以通过将theGadget字段声明为volatile来绕过(注意,只在新版本的内存模型下才有效),不过却会增加执行开销。尽管比使用synchronized方法小,但还是有的。为清楚起见,如果不缺点当前版本的内存模型是否实现正确的话。不要使用双检查锁。网上有很多文章介绍了为什么不应该使用双检查锁,不仅限于Java,其他语言也是。

双检查锁的危险之处在于,在强内存模型下,它很少会使程序崩溃。Intel IA-64平台就是个典型,其弱内存模型臭名远扬,原本好好运行的Java应用程序可能不知到啥时就出问题了。如果某个应用程序在x86平台运行良好,在x64平台却出问题,人们很容易怀疑是JVM的bug,却忽视了有可能是Java应用程序自身的问题。

使用静态域来实现单例模式可以实现同样的语义,而无需使用双检查锁,如下所示:

public class GadgetMaker {
    public static Gadget theGadget = new Gadget();
}

Java语言保证类的初始化是原子操作,由于GadgetMaker类中没有其他的域,因此,在首次引用(译者注,这里应该是"主动引用","被动引用"并不会执行类的初始化)该类时会自动创建Gadget类的实例。并赋值给theGadget字段。这种方法在新旧两种内存模型下均可正常工作。

译者注:

  • "主动引用"和"被动引用"的说法参见周志明编写的《深入理解Java虚拟机》
  • Java语言规范中定义了在何种情况下才会执行类的初始化,如下:
    • T是一个类,则创建T时会执行类的初始化
    • T是一个类,则调用由T声明的静态方法会执行类的初始化
    • 对类型T声明的静态域赋值会执行类的初始化
    • 访问类型T声明的常量不会执行类的初始化
    • 如果T是一个顶层类(top level class),并且内嵌其中断言语句被执行,则会执行类的初始化
    • 通过java.lang.reflect包中类和java.lang.Class类以反射的方式调用
  • 除上述情况外,其他情况均不会触发类的初始化,相关验证参见gist

使用Java做并行程序开发有很多需要小心的地方,如果能够正确理解Java内存模型,那么是可以避开这些陷阱的。进一步说,开发人员往往不太关心当前的硬件架构,但如果不能理解内存模型的话,就迟早会搬起石头砸自己的脚。